fix(soccer-scoreboard): correct TEAMS.md codes, explain why a league is empty - #233
Conversation
…is empty A user set their Premier League favourite to "MUN" for Manchester United, straight out of TEAMS.md, and saw nothing. Two separate problems. TEAMS.md was wrong. ESPN uses MAN for Manchester United and MNC for Manchester City; the file said MUN and MCI. It was not isolated — Real Madrid was RM rather than RMA, Ligue 1 had eight wrong codes including Lyon (LYON not OL) and Marseille (OLM not OM), Bundesliga four, and several rosters were a season out of date. Every table is now generated from ESPN's live team endpoints and verified against them, cross-checked with the standings endpoint. Worse than a typo: MUN is a real code — it is Bayern Munich. Favourites match by abbreviation across every enabled league, so anyone following the old docs with the Bundesliga also enabled would have quietly followed the wrong club. TEAMS.md now lists the codes that mean different clubs in different leagues. The second problem is that this failed silently. Matching is an exact string comparison against ESPN's abbreviation with no aliasing, so a plausible code matches nothing and the display just stays empty. Between seasons a *correct* code produces exactly the same empty screen, and the logs could not tell them apart. Once per league the plugin now reports which it is: favorite team 'MUN' is not a Premier League team code. Closest match is 'MAN' (Manchester United). See TEAMS.md for the full list. Premier League favorite teams MAN look correct, but the league has no fixtures published yet — its season starts 21 August 2026. An empty display until then is expected, not a configuration problem. Suggestions match how people abbreviate rather than by string distance, which is useless at three characters: 'MUN' scores identically against 'MAN' and 'SUN', so Manchester United and Sunderland tie and the hint is a coin flip. Requiring each part of the code to prefix a word of the club name separates them — 'MUN' splits as M-anchester UN-ited, while Sunderland offers no word starting with M. Verified against all seven leagues' real data: MUN->MAN, MCI->MNC, MANU->MAN, RM->RMA, OM->OLM, ASM->MON, and a wrong-case code is told so explicitly. The check is diagnostic only: it runs once per league, is never retried on failure, and any error is swallowed so it cannot disturb updates. Harness passes at all 24 screens; existing soccer tests still pass. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
|
Warning Review limit reached
Next review available in: 14 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughThe soccer scoreboard plugin now validates favorite-team abbreviations against ESPN data, reports invalid or not-yet-scheduled teams, documents refreshed league mappings, adds diagnostic tests, and releases version 2.4.0. ChangesSoccer scoreboard release
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant ScoreboardPlugin
participant LeagueManagers
participant ESPN
participant Logger
ScoreboardPlugin->>LeagueManagers: read favorite_teams
ScoreboardPlugin->>ESPN: fetch team codes and season data
ESPN-->>ScoreboardPlugin: return mappings and fixture state
ScoreboardPlugin->>Logger: log warnings or informational diagnostics
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 107 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@plugins.json`:
- Line 3: Update the manifest’s release metadata for version 2.4.0 so the
top-level last_updated value is 2026-07-29, ensuring update_registry.py
propagates the correct date instead of the soccer entry’s stale 2026-07-17
value.
In `@plugins/soccer-scoreboard/manager.py`:
- Around line 1413-1455: Update _fetch_league_teams and _fetch_season_start to
read and refresh their ESPN responses through self.cache_manager using
plugin-namespaced cache keys, rather than making uncached synchronous requests.
Schedule cache refreshes from update so diagnostics are populated asynchronously
and cannot delay scoreboard update threads, while preserving the existing
parsing and diagnostic behavior.
In `@plugins/soccer-scoreboard/TEAMS.md`:
- Line 18: Update the heading in TEAMS.md from H3 to H2 so it follows the
document title and preserves heading hierarchy and generated navigation.
- Around line 20-23: Update the favorites-matching explanation in TEAMS.md to
state that each manager’s favorite_teams comes from its own league
configuration. Clarify that abbreviation matching is scoped to configured
favorites per league, so enabling multiple leagues alone does not make an eng.1
favorite select a Bundesliga team; duplicate codes match across leagues only
when configured in each league.
🪄 Autofix (Beta)
✅ Autofix completed
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: ed4dbcb0-9152-4724-a879-8fe49f88817f
📒 Files selected for processing (6)
plugins.jsonplugins/soccer-scoreboard/CHANGELOG.mdplugins/soccer-scoreboard/TEAMS.mdplugins/soccer-scoreboard/manager.pyplugins/soccer-scoreboard/manifest.jsonplugins/soccer-scoreboard/test_favorite_team_diagnostics.py
| def _fetch_league_teams(self, league_key: str) -> Dict[str, str]: | ||
| """ESPN's {abbreviation: display name} for a league.""" | ||
| import requests | ||
|
|
||
| url = ("https://site.api.espn.com/apis/site/v2/sports/soccer/" | ||
| "{}/teams".format(league_key)) | ||
| payload = requests.get(url, timeout=15).json() | ||
| entries = payload['sports'][0]['leagues'][0]['teams'] | ||
| return { | ||
| t['team']['abbreviation']: t['team']['displayName'] | ||
| for t in entries if t.get('team', {}).get('abbreviation') | ||
| } | ||
|
|
||
| def _fetch_season_start(self, league_key: str) -> Optional[str]: | ||
| """ | ||
| First fixture date when a league has none scheduled yet, else None. | ||
|
|
||
| ESPN keeps publishing a league's calendar between seasons while its | ||
| scoreboard is empty, which is exactly the state that looks like a broken | ||
| config. | ||
| """ | ||
| import requests | ||
| from datetime import datetime, timezone | ||
|
|
||
| url = ("https://site.api.espn.com/apis/site/v2/sports/soccer/" | ||
| "{}/scoreboard".format(league_key)) | ||
| payload = requests.get(url, timeout=15).json() | ||
|
|
||
| if payload.get('events'): | ||
| return None # fixtures exist; an empty screen is not about the season | ||
|
|
||
| calendar = (payload.get('leagues') or [{}])[0].get('calendar') or [] | ||
| for entry in calendar: | ||
| raw = entry if isinstance(entry, str) else entry.get('startDate') | ||
| if not raw: | ||
| continue | ||
| try: | ||
| when = datetime.fromisoformat(raw.replace('Z', '+00:00')) | ||
| except ValueError: | ||
| continue | ||
| if when > datetime.now(timezone.utc): | ||
| return when.strftime('%d %B %Y') | ||
| return None |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Do not block updates on uncached diagnostics.
These direct requests run synchronously before manager update threads start, so an ESPN stall can delay each enabled league by up to 30 seconds. Cache the responses through self.cache_manager with plugin-namespaced keys, and schedule refreshes so diagnostics cannot hold up scoreboard updates.
As per coding guidelines: “Fetch or refresh network data in update, never in display, and use the shared self.cache_manager for network-fetched data” and “Namespace cache keys with the plugin ID.”
🧰 Tools
🪛 ast-grep (0.45.0)
[warning] 1418-1418: Request-controlled URL passed to requests; validate against an allowlist to prevent SSRF.
Context: requests.get(url, timeout=15)
Note: [CWE-918] Server-Side Request Forgery (SSRF).
(ssrf-requests)
[warning] 1438-1438: Request-controlled URL passed to requests; validate against an allowlist to prevent SSRF.
Context: requests.get(url, timeout=15)
Note: [CWE-918] Server-Side Request Forgery (SSRF).
(ssrf-requests)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@plugins/soccer-scoreboard/manager.py` around lines 1413 - 1455, Update
_fetch_league_teams and _fetch_season_start to read and refresh their ESPN
responses through self.cache_manager using plugin-namespaced cache keys, rather
than making uncached synchronous requests. Schedule cache refreshes from update
so diagnostics are populated asynchronously and cannot delay scoreboard update
threads, while preserving the existing parsing and diagnostic behavior.
Source: Coding guidelines
| Manchester City (not `MCI`), `RMA` for Real Madrid (not `RM`), and `LYON` / | ||
| `OLM` for Lyon and Marseille (not `OL` / `OM`). | ||
|
|
||
| ### Codes that mean different clubs in different leagues |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Use an H2 heading here.
### skips directly from the document title to H3, breaking heading hierarchy and generated navigation.
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)
[warning] 18-18: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3
(MD001, heading-increment)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@plugins/soccer-scoreboard/TEAMS.md` at line 18, Update the heading in
TEAMS.md from H3 to H2 so it follows the document title and preserves heading
hierarchy and generated navigation.
Source: Linters/SAST tools
| Favourites are matched by abbreviation across every league you have enabled, so | ||
| if two of your enabled leagues share a code you will match both. The one to | ||
| watch is `MUN`, which is **Bayern Munich** in the Bundesliga — not Manchester | ||
| United. |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Clarify that favorites are configured per league.
Each manager receives favorite_teams from its own league config. A code only matches multiple leagues when it is configured under each of those leagues; merely enabling both leagues does not make an eng.1 favorite select a Bundesliga team.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@plugins/soccer-scoreboard/TEAMS.md` around lines 20 - 23, Update the
favorites-matching explanation in TEAMS.md to state that each manager’s
favorite_teams comes from its own league configuration. Clarify that
abbreviation matching is scoped to configured favorites per league, so enabling
multiple leagues alone does not make an eng.1 favorite select a Bundesliga team;
duplicate codes match across leagues only when configured in each league.
…mes release Both branches bumped soccer-scoreboard to 2.4.0 — main's is the UTC start-time fix (#228). Rebased this work onto 2.5.0 so main's release notes are preserved beneath it rather than replaced. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
|
Note Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it. Fixes Applied SuccessfullyFixed 4 file(s) based on 4 unresolved review comments. Files modified:
Commit: The changes have been pushed to the Time taken: |
Review follow-ups on #239. f1-scoreboard's manifest disagreed with itself: last_updated said 2026-07-28 while its newest versions[] entry for the same 1.7.1 said 2026-08-01. update_registry.py mirrors last_updated into the catalog, so the stale date propagated there too. Set it from the release entry and regenerated plugins.json. The 08-shared-sports-code doc still showed `except ImportError` for the base_odds_manager guard, but the code was narrowed to ModuleNotFoundError with a name check in an earlier round -- so the doc was teaching the pattern its own examples no longer use. A bare ImportError also swallows failures raised *inside* a core module that is present, silently loading the bundled copy and hiding a broken install. clock-simple's font loader caught bare Exception; narrowed to OSError, which is what FreeType raises for a missing, unreadable or malformed face. All three committed goldens pass unchanged. Left alone: - The measurement fallback in clock-simple stays broad on purpose. It runs on the render path and deliberately degrades to draw_text's own centring; letting a measurement hiccup propagate would blank a clock. - soccer-scoreboard's _check_favorite_teams blocking update() is real, but it arrived in #233 (49b0fe2) from main and is not this PR's code. - Five other manifests carry the same date drift from earlier PRs; out of scope here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
… plugin fleet (#239) * chore: remove dead files with zero importers hockey-scoreboard/base_classes.py and scoreboard_renderer.py are stale near-duplicates of hockey.py's live code; basketball_helpers.py is an unused font-loading helper. Nothing imports any of them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * perf: cache per-render font loads in of-the-day, web-ui-info, on-air - of-the-day: the classic-fallback fonts (used when the core lacks src.element_style) were reloaded from disk on every render; they are config-independent, so load once and reuse. - web-ui-info: display() reloaded the 4x6 font on every call. - on-air: the shrink-to-fit path reloaded the scaled TTF each frame for wide labels; now memoized by (path, size). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * docs: shared sports code lineage map, scroll-key semantics, 8-size harness matrix - New 08-shared-sports-code.md: the three scoreboard lineages, per-module drift table, guarded convergence pattern, sunset rule for local copies, and the fix-all-lineage-members-in-one-PR rule (commit 8d33894 as the cautionary example). - 03-advanced-features.md: document the three incompatible scroll_speed unit semantics (px/frame, px/second, inverted frames-per-step divisor). - 07-testing-ci-and-registry.md: the harness default matrix is 8 sizes, not 4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * feat: standardize font/size/color customization and x-advanced scroll keys Accessibility rollout, additive only — default rendering is byte-identical in every plugin (custom faces load only when config differs from the schema default, so web-UI merged-defaults configs are unaffected): - clock-simple: honor the customization font/font_size keys the schema already declared (code previously read only text_color); existing golden images pass unchanged. - news: per-element headline_text/source_text font+size; source line color was previously hardcoded (150,150,150) and is now configurable. - mqtt-notifications: message_text font face+size (single text style). - countdown: name_font_family — the name line previously had per-element size/color but shared the value line's font face. - tide-display: tide_text/label_text font+size+color, routed by the existing palette constants; chart colors untouched. - youtube-stats: channel_name/subscriber_count/view_count font+size+ color (plugin previously had zero styling config). - x-advanced added to scroll_speed/scroll_delay fine-tuning keys in nrl, ufc, elections, stocks, leaderboard, news, odds-ticker, march-madness, text-display schemas (UI hint only). text-display and on-air deliberately unchanged: their existing config surface already covers font face, size, and colors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * feat: honor global target_fps in elections, stocks, nfl-draft, march-madness, text-display Non-scoreboard half of the global-FPS rollout (canonical pattern from ledmatrix-leaderboard): read global_config target_fps/scroll_target_fps, probe set_target_fps with hasattr, clamp 30-200 with a frame_time_target fallback for older ScrollHelper builds. - elections, stocks: adopt the block (previously ignored the global FPS). - nfl-draft: also set an explicit scroll_delay (pacing was previously left at the helper default and unconfigured). - march-madness: plugin-level display_options.target_fps still wins, falls back to the global; added the missing older-core fallback branch. - text-display: added the hasattr guard + fallback (its 240 ceiling was already clamped to 200 inside the core helper). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * test: harness fixtures for 8 plugins, goldens for 6; fix of-the-day 64px overflow Deterministic test/harness.json for countdown, christmas-countdown, 7-segment-clock, text-display, static-image, web-ui-info, of-the-day, on-air (config + freeze_time archetype; static-image renders a bundled test-pattern PNG). Golden images committed for the six deterministic renderers — every golden was generated twice and byte-compared before committing. No goldens for web-ui-info (renders the host IP) or on-air (active state is event-driven). The of-the-day fixture immediately exposed a real bug: on 64px-wide panels the title (PressStart2P@8) and its underline drew past the right edge, and body lines ran past the bottom on 64x32. Titles now ellipsize to the panel width, the underline is clamped inside the panel, and body lines stop before the bottom edge. Wider panels render identically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * chore: bump versions for all plugins changed so far Sports scoreboards still receiving font-cache/odds/fps work (afl, baseball, football, soccer, lacrosse, f1) will be bumped with those changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * fix: address review findings on FPS sourcing, panel clamps, and fixtures - elections/stocks/nfl-draft/march-madness: source the FPS target from the plugin config's 'global' section (the news/leaderboard convention) instead of a never-set attribute; elections also refreshes it in on_config_change. - text-display: single _apply_target_fps helper used by init and on_config_change — the latter previously called set_target_fps unconditionally and would raise AttributeError on older cores; the stored value is now the effective 30-200 clamped rate. - of-the-day: clamp title_x after the user layout offset; guard _fit_title against a panel too narrow for the ellipsis itself. - clock-simple: clamp the combined time+AM/PM block to start on-panel when a user-selected font exceeds the width. - youtube-stats: truncate the channel name by measured width and derive row height from the selected fonts when a custom font is set (identical layout at the monospace default); validate color length in _rgb. - tide-display: same color-length validation; unfold the one-line try/except flagged by Ruff E701. - countdown fixture: pin font/color schema defaults explicitly so a future default change cannot silently invalidate the goldens. All affected plugins re-verified with the harness; clock-simple, countdown, and of-the-day goldens pass unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * fix(text-display): resolve font_path against the core install, not just cwd CI runs the safety harness from the workspace root rather than the core checkout, so the plugin's relative font_path missed assets/fonts and fell back to PIL's default font — mismatching the committed goldens (generated with the real font). Add a resolution strategy that walks up from the display manager's module location to find the core's assets, making font loading cwd-independent. Goldens now pass from both working directories. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * perf(football-scoreboard): cache the record font instead of reloading per frame The scorebug draw paths (SportsUpcoming/SportsRecent in sports.py, FootballLive in football.py) reloaded 4x6-font.ttf from disk on every rendered frame when records/rankings are shown; game_renderer's _draw_records_or_rankings did the same unconditionally. The face is now cached once in _load_fonts (fonts['record']) with a lazy memo in the renderer, using the accessor pattern ufc-scoreboard already ships. Rendering is pixel-identical: the full adaptive-layout and score- celebration golden suites (46 tests) pass unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * perf(hockey,lacrosse): cache record/shots fonts instead of reloading per frame Same treatment as football-scoreboard: fonts['record'] and fonts['shots'] cached in _load_fonts, scorebug draw sites use the accessor pattern, and game_renderer's fallback disk load is memoized (its detail-font primary lookup — a real lineage difference from football — is preserved). Also fixes a latent crash: hockey.py's shots font load had no try/except, so a missing 4x6-font.ttf killed the live render instead of degrading. Harness output is byte-identical to the pre-change baselines for both plugins; offline test scripts (favorite-live-boost, non-favorite-live- duration, timezone-resolution) all pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * chore: bump football (2.10.0) and lacrosse (1.6.0) for the font-caching change Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * perf(afl,baseball,soccer): cache record/ranking font instead of reloading per frame Completes the font-caching sweep started for football/hockey/lacrosse (cf93709, 640c081). Same treatment across the three remaining sports-scoreboard lineages: - afl-scoreboard: sports.py had three separate uncached reload sites (SportsUpcoming/SportsRecent ranking overlays); game_renderer.py was already caching fonts["record"] in _load_fonts. Added the same cache to sports.py's _load_fonts and switched all three sites to the self.fonts.get("record") accessor. - baseball-scoreboard: sports.py had two uncached reload sites; fixed the same way. game_renderer.py already reused fonts['detail'], no change needed there. - soccer-scoreboard: sports.py had three uncached reload sites, and game_renderer.py's _draw_records_or_rankings reloaded unconditionally on every call (no fonts-dict entry at all) -- given the football game_renderer lazy-memo treatment (getattr(self, '_record_font', ...)). f1-scoreboard checked and needs no change: its renderer loads all fonts once in __init__ (f1_renderer.py:180), no per-frame reload pattern exists there. Verified pixel-identical: rendered afl/baseball/soccer live+recent+ upcoming at 128x64 with show_records and show_ranking forced on, before and after the change (via git stash) -- byte-for-byte identical PNGs in all cases. Full safety harness (all 8 sizes) passes for all three plugins with no new warnings. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * perf(basketball,nrl,ufc,baseball): finish the record-font caching sweep Completes the lineage-wide font-caching change (football, hockey, lacrosse, afl, soccer landed earlier): - basketball: record + tournament-date fonts cached in _load_fonts; game_renderer memoizes its record font. - nrl: record font cached; game_renderer memoized. - ufc: upcoming/recent scorebug paths use the cached record font the plugin already loaded for its live layout. - baseball: (name,size)-keyed memo in _load_custom_font_from_element_config plus a BDF native-size cache, collapsing the per-frame 10-rung font ladder walks in the traditional-scoreboard and at-bat screens into dict lookups; the record-font block moves after the config fallback so it is set on every path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * refactor(sports): prefer the core-shipped odds manager with a bundled fallback The eight non-UFC scoreboards imported their local base_odds_manager copy unconditionally. They now try src.base_odds_manager first (a functional superset: adds cache_ttl support) and fall back to the bundled copy on cores that don't ship it. Both branches are module-level, so they stay collision-safe under the loader's bare-name isolation. In afl/nrl/soccer/basketball manager.py the odds import sat inside the combined BasePlugin guard, so a missing odds module would have nulled BasePlugin (and in nrl's case NameError'd — its except branch never set BaseOddsManager). It now has its own nested guard. UFC keeps its local copy unconditionally: it is a genuine MMA fork (athlete odds), not a drifted duplicate. Local copies stay bundled per the sunset rule in docs/plugin-development/08-shared-sports-code.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * fix(countdown): load family fonts cwd-independently so CI goldens match The safety harness on CI runs from the workspace root, not the core checkout, so the core FontManager's cwd-relative assets/fonts scan came up empty and resolve_font silently degraded to PIL's default face — drifting every committed golden (the named failure in the last two safety runs). The type of the degraded font gives no signal (current Pillow's load_default() is itself a FreeTypeFont), so the miss is detected via the manager's font catalog, and the family's real file is then resolved against the core install the display manager was loaded from (same strategy text-display already uses). Verified: goldens pass from both the core root and a foreign cwd. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * feat(sports): honor the global target_fps in every scoreboard's scroll mode The ten scroll_display.py files paced frames only from scroll_delay, so the global smooth-scrolling FPS setting (target_fps / scroll_target_fps) silently never reached the sports scoreboards. ScrollDisplay and ScrollDisplayManager now accept a trailing global_config kwarg (threaded from every manager construction site), and _configure_scroll_helper applies the canonical set_target_fps block with the clamped frame_time_target fallback for older cores. When no global value is set, pacing is unchanged. Also removes the dead _get_target_fps helpers in afl/nrl/soccer whose value was computed but never applied, and bumps f1 to 1.8.0 (its only change in this PR). Verified: harness green on all ten plugins (f1's committed goldens pass unchanged); football adaptive/celebration and soccer celebration pytest suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * fix: address review findings on measurement fallback and registry dates - tide-display: when display-manager measurement fails, fall back to the drawing font's own getbbox metrics before the 4px/char estimate, so a custom face never draws wider than the width reported. - text-display, countdown: log module-relative font-probe failures at debug instead of swallowing them silently. - afl/baseball manifests carried a stale last_updated (2026-07-17) that the registry echoed; set to 2026-07-31 and teach update_registry.py to sync last_updated even when latest_version is unchanged, so catalog timestamps can no longer drift from manifests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * ci: don't echo unvalidated plugin ids in the harness failure summary Plugin ids are derived from PR file paths, so in the invalid-id branch the raw string is fork-controllable; echoing it into workflow commands is needless exposure. Redact it and record a placeholder in the summary instead — valid ids (the useful signal) are unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * fix(ufc): enable the scoreboard managers; test: mock fixtures for six scoreboards The UFC managers pass sport_key='ufc_scoreboard' and SportsCore looked up config under f'{sport_key}_scoreboard' — the nonexistent 'ufc_scoreboard_scoreboard' — so mode_config was always empty, is_enabled always False, and every UFC screen rendered blank. The lookup now uses the key the adapter actually writes ('ufc_scoreboard'). Found while building harness fixtures: no cache/mock/config contents could make the managers render. Adds deterministic harness fixtures (config + cached-schedule mock data + frozen clock) for baseball, basketball, football, hockey, lacrosse, and soccer, so the safety harness renders real game cards for recent/upcoming (and live where the plugin's live path reads the cache: basketball, soccer, plus baseball via its test_mode passthrough) instead of blank no-data screens. Where the live path is a direct network fetch with no cache read (hockey, football, lacrosse), live is disabled in the fixture with the reason documented in each harness.json. Verified: all sizes PASS and two consecutive runs are byte-identical for every plugin. UFC gets no fixture yet: its fighter-headshot loader has no negative caching and no config toggle, so offline runs spend ~15s of retries per headshot per render — needs a small plugin change first (follow-up). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * fix: address CodeRabbit review findings across scoreboards, tide, countdown - nrl manager: remove a stray unconditional BaseOddsManager = None that overwrote a successful bundled-fallback import on older cores (critical review catch). - soccer + afl sports.py: move the cached record-font block after the config/fallback branches so it is set on every path, matching the other seven copies. - hockey manager: drop the duplicate ScrollDisplayManager construction; the first instance (which enable_scrolling reads) now persists instead of being silently replaced. - all ten scroll_display.py: coerce target_fps to float before comparing so a malformed global config degrades to scroll_delay pacing instead of raising in __init__. - tide-display: resolve custom fonts against the core install (cwd- independent, same strategy as text-display/countdown) and clamp the three label positions that could go negative or overflow with large custom fonts. - baseball sports.py: narrow the BDF strike-size retry to OSError and cache the fallback-default font under the requested key so a misconfigured font stops hitting the disk per frame. - countdown: log per-candidate font-load failures instead of silently continuing, and memoize the FontManager catalog-miss check that was statting the filesystem on every render. The bare-name fallback module rename suggestion is deliberately not applied — module-level bare imports are collision-safe under the core loader's isolation rules (see the review reply and 08-shared-sports-code.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * docs: record how plugins read device-wide settings target_fps had no copy to converge -- self.config is only the plugin's own slice, so a device-wide value simply wasn't reachable, which is why the scoreboards' getattr(self, 'global_config', ...) always saw {}. Documents the core-side property added in ChuckBuilds/LEDMatrix#424: the resolution order, reading it as getattr(...) so plugins still load on older cores, that it is read-only (mutating the live config has bitten this repo before), and that assignment still overrides it -- which news, stock-news, ledmatrix-stocks, ledmatrix-elections, ledmatrix-leaderboard and nfl-draft depend on. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * fix: address CodeRabbit round-2 findings (import narrowing, docs, probe logging) - The odds-manager convergence guards now catch ModuleNotFoundError and check exc.name, so only an absent core module triggers the bundled fallback; an import failure from inside src.base_odds_manager (missing dependency) surfaces instead of being masked. Applied to all eight sports.py guards and the four manager.py nested guards; verified both branches with meta-path simulations. - 08-shared-sports-code.md: the global_config example now uses the getattr form the same section mandates. - tide-display: the module-relative font probe logs failures at debug level instead of a blind except/pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * chore: reclassify the scoreboard releases as PATCH CONTRIBUTING.md scopes MINOR to new features and schema additions. None of these ten plugins gained either: the changes are font-load caching, the odds-manager import switch, target_fps threading, and test fixtures. The only schema edits (nrl, ufc) add `x-advanced` UI hints, not options. That makes them PATCH. Each new version is set one patch above **main's** current version rather than above the branch's, which also fixes a collision: soccer had been bumped to 2.5.0 here while main independently released its own 2.5.0, so two different sets of changes shared a version number. Now 2.5.1. plugins.json could not simply be regenerated -- update_registry.py refuses to lower a version, so it skipped all ten and left the registry advertising the old MINOR numbers. Reset the generated file to main's state and regenerated from there, which the tool accepts as an increase. Verified afterwards that every latest_version moves up and none moves down. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * fix(clock-simple): stop an oversized date clipping at both ends The date and weekday draws passed no x, so draw_text auto-centred them with (width - text_width) // 2 and no clamp. _fit_date returns its shortest candidate even when that still overflows -- reachable now that the font is user-configurable -- and a negative x clips the string at *both* ends, losing the leading characters rather than just the tail. Adds _centered_x, which clamps to 0 so overflow clips on the right only. It returns None when measuring raises, deferring to draw_text's own centring rather than guessing, mirroring _text_fits assuming a fit so a measurement failure never hides content. The time and AM/PM paths already clamp (max(0, ...) on time_x, and max(0, min(...)) on ampm_x), so this closes the remaining case. Defaults are unchanged: all three committed goldens still match byte for byte, harness green at all 8 sizes. Verified the fix directly -- "Aug 1st" at 84px on a 64px panel gives draw_text x=-10 vs _centered_x x=0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * docs: add the validated scroll-display adoption recipe Core 3.2.0 ships src/common/sports_scroll.py, the orchestration half of scroll_display.py. The content half (prepare_scroll_content, _load_separator_icons) stays per-plugin permanently -- a survey of the eight copies that share a shape found eight distinct bodies, because each draws its own game card. Same method name, different job. Documents the mechanical adoption once a plugin floors at 3.2.0, the byte-comparison acceptance gate, and the two gotchas the hockey pilot surfaced (the inherited os.path use in _load_separator_icons, and the now-dead scroll_helper guards). Measured on hockey-scoreboard: 691 -> 289 lines, all 16 harness renders byte-for-byte identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 * fix: sync f1 release metadata, correct the doc's import example Review follow-ups on #239. f1-scoreboard's manifest disagreed with itself: last_updated said 2026-07-28 while its newest versions[] entry for the same 1.7.1 said 2026-08-01. update_registry.py mirrors last_updated into the catalog, so the stale date propagated there too. Set it from the release entry and regenerated plugins.json. The 08-shared-sports-code doc still showed `except ImportError` for the base_odds_manager guard, but the code was narrowed to ModuleNotFoundError with a name check in an earlier round -- so the doc was teaching the pattern its own examples no longer use. A bare ImportError also swallows failures raised *inside* a core module that is present, silently loading the bundled copy and hiding a broken install. clock-simple's font loader caught bare Exception; narrowed to OSError, which is what FreeType raises for a missing, unreadable or malformed face. All three committed goldens pass unchanged. Left alone: - The measurement fallback in clock-simple stays broad on purpose. It runs on the render path and deliberately degrades to draw_text's own centring; letting a measurement hiccup propagate would blank a clock. - soccer-scoreboard's _check_favorite_teams blocking update() is real, but it arrived in #233 (49b0fe2) from main and is not this PR's code. - Five other manifests carry the same date drift from earlier PRs; out of scope here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4 --------- Co-authored-by: Claude <noreply@anthropic.com>
Reported problem
A user set their Premier League favourite to
MUNfor Manchester United — straight out of TEAMS.md — and saw nothing on the display. Two separate problems, and the docs were the first one.1. TEAMS.md had wrong codes
ESPN uses
MANfor Manchester United andMNCfor Manchester City. The file saidMUNandMCI. It wasn't isolated — diffing every documented code against ESPN's live endpoints:MCI→MNC,MUN→MANRM→RMAAUG→FCA,BAY→MUN,BVB→DOR,MNZ→M05ROM→ROMA,COM→COMOOL→LYON,OM→OLM,ASM→MON,TFC→TOU, …Every table is now generated from ESPN's live team endpoints and verified against them. I cross-checked the roster against the standings endpoint too, since the pre-season team list looked surprising — both agree exactly.
Worse than a typo:
MUNis a real code — it's Bayern Munich. Favourites match by abbreviation across every enabled league, so anyone following the old docs with the Bundesliga also on would have quietly followed the wrong club. TEAMS.md now documents the codes that mean different clubs in different leagues (MUN,BRE,MON,PAR,SCP,FCA,TOR).2. It failed silently, and two different causes looked identical
Matching is an exact string comparison against ESPN's abbreviation with no aliasing (
DynamicTeamResolver.resolve_teamsis a pass-through for soccer), so a plausible code matches nothing and the display just stays empty.The trap is that between seasons a correct code produces exactly the same empty screen — and the logs couldn't tell them apart. In this user's case both were true:
MUNis wrong, and the 2026-27 Premier League doesn't start until 21 August, so fixing the code alone wouldn't have made anything appear.Once per league, the plugin now says which it is:
On the suggestions
String distance is useless at three characters:
MUNscores identically againstMANandSUN, so Manchester United and Sunderland tie and the hint becomes a coin flip — my first attempt duly suggested Sunderland.So suggestions match how people actually abbreviate: each part of the code must prefix a word of the club name, in order.
MUNsplits as M-anchester UN-ited, while Sunderland offers no word starting with M. Validated against all seven leagues' real data —MUN→MAN,MCI→MNC,MANU→MAN,RM→RMA,OM→OLM,ASM→MON— and a wrong-case code is told so explicitly, since matching is case-sensitive.Safety
The check is purely diagnostic: once per league per process, never retried on failure, every error swallowed so it cannot disturb updates. It re-runs on config change so a corrected code gets confirmed rather than staying silent.
Testing
test_favorite_team_diagnostics.py, including the exact reported case.test_live_mode_targeting,test_non_favorite_live_duration,test_world_cup_flags) still pass.check_module_collisions.py: OK across 41 plugins.Note for reviewers
This matching pattern is shared with the other scoreboard plugins, so the same silent-failure mode exists there.
nrl-scoreboardwas already moved to matching on ESPN team ID rather than abbreviation (#189) for related reasons — worth considering more widely, since ESPN can change abbreviations on promotion and relegation. Happy to port the diagnostic to the other sports if useful.🤖 Generated with Claude Code
https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
Summary by CodeRabbit
New Features
Documentation
Bug Fixes